본문으로 건너뛰기

5장 리액트와 상태 관리 라이브러리

1. 상태 관리는 왜 필요한가?

UI, URL, FORM, 서버에서 받은 데이터 등의 상태의 일관성을 유지하고 성능을 최적화 시켜 유지보수를 용이하게 만든다.

1. 리액트 상태 관리의 역사

1. Flux 패턴의 등장

웹 애플리케이션이 비대해지고 상태가 많아짐에 따라 상태의 추적이 어려워 데이터의 흐름을 단순화 시키기 위해 만들어졌다. ActionDispatcherModelView 의 단방향 데이터 흐름을 가진다.

2. 리덕스의 등장

하나의 상태 객체를 스토어에 저장해 두고, 이 객체를 업데이트하는 작업을 디스패치해 업데이트를 수행한다. proops를 전역으로 관리해 하위 컴포넌트에 전파할 수 있다.

3. Context API와 useContext

proops의 단순 상태 참조시 리덕스 사용 부담이 컸기 때문에 만들어졌다. Context를 생성하고 훅을 사용해 호출한다.

4. 훅의 탄생, 그리고 React Query와 SWR

훅의 발전으로 함수 컴포넌트의 사용이 많아지게 되었고 상태 관리가 쉬워지게 되었다. 이에 따라 데이터를 불러오는데 특화된 라이브러리가 등장하게 된다.

import React from 'react'
import useSWR from 'swr'
const fetcher = (url) => fetch(url).then((res) => res.json())
export default function App() {
const { data, error } = useSWR(
'https://api.github.com/repos/vercel/swr',
fetcher,
)
if (error) return 'An error has occurred.'
if (!data) return 'Loading...'
return (
<div>
<p>{JSON.stringify(data)}</p>
</div>
)
}

2. 리액트 훅으로 시작하는 상태 관리

1. useSubscription

스토어를 생성 후 훅을 사용해 상태를 구독하고 상태가 변하면 UI를 업데이트 하는 방식이다.

// store.js 
const store = {
state: { count: 0 },
listeners: new Set(),

setState(newState) {
this.state = { ...this.state, ...newState };

this.listeners.forEach(listener => listener(this.state));
},


subscribe(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
},
};

export default store;

// useSubscription.js 
import { useState, useEffect } from "react";
import store from "./store";

const useSubscription = () => {
const [state, setState] = useState(store.state);

useEffect(() => {

const unsubscribe = store.subscribe(setState);


return () => unsubscribe();
}, []);

return state;
};

export default useSubscription;

import React from "react";
import { useSubscription } from "./useSubscription";
import store from "./store";

const Counter = () => {
const { count } = useSubscription();

const increment = () => {
store.setState({ count: count + 1 });
};

return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
</div>
);
};

export default Counter;

2. useState와 Context를 동시에 사용해 보기

컴포넌트는 하나의 스토어만 가질 수 있는데 이 문제를 해결하기 위해 스토어를 여러개 만들고 데이터를 공유하게 할 수 있는 방법이다.

export const useCounterContextSelector = <State extends unknown>(
selector: (state: CounterStore) => State,
) => {
const store = useContext(CounterStoreContext)
// useStoreSelector를 사용해도 동일하다.
const subscription = useSubscription(
useMemo(
() => ({
getCurrentValue: () => selector(store.get()),
subscribe: store.subscribe,
}),
[store, selector],
),
)
return [subscription, store.set] as const
}
const ContextCounter = () => {
const id = useId()
const [counter, setStore] = useCounterContextSelector(
useCallback((state: CounterStore) => state.count, []),
)
function handleClick() {
setStore((prev) => ({ ...prev, count: prev.count + 1 }))
}
useEffect(() => {
console.log(`${id} Counter Rendered`)
})
return (
<div>
{counter} <button onClick={handleClick}>+</button>
</div>
)
}
const ContextInput = () => {
const id = useId()
const [text, setStore] = useCounterContextSelector(
useCallback((state: CounterStore) => state.text, []),
)
function handleChange(e: ChangeEvent<HTMLInputElement>) {
setStore((prev) => ({ ...prev, text: e.target.value }))
}
useEffect(() => {
console.log(`${id} Counter Rendered`)
})
return (
<div>
<input value={text} onChange={handleChange} />
</div>
)
}

export default function App() {
return (
<>
{/* 0 */}
<ContextCounter />
{/* hi */}
<ContextInput />
<CounterStoreProvider initialState={{ count: 10, text: 'hello' }}>
{/* 10 */}
<ContextCounter />
{/* hello */}
<ContextInput />
<CounterStoreProvider initialState={{ count: 20, text: 'welcome' }}>
{/* 20 */}
<ContextCounter />
{/* welcome */}
<ContextInput />
</CounterStoreProvider>
</CounterStoreProvider>
</>
)
}

각 컴포넌트 트리에서 상태를 독립적으로 관리하며 다른 컴포넌트에서 동적상태를 업데이트 할 수 있게 된다.

3. Recoil, Jotai, Zustand 살펴보기

1. Recoil

Atoms 라는 기본 단위를 사용하며 상태를 정의하고 selector로 파생 상태를 계산한다. 최상위 컴포넌트에 RecoilRoot로 애플리케이션을 감싸서 사용한다.

const counterState = atom({
key: 'counterState',
default: 0,
})
function Counter() {
const [, setCount] = useRecoilState(counterState)
function handleButtonClick() {
setCount((count) => count + 1)
}
return (
<>
<button onClick={handleButtonClick}>+</button>
</>
)
}
// atom을 기반으로 또 다른 상태를 만들 수 있다.
const isBiggerThan10 = selector({
key: 'above10State',
get: ({ get }) => {
return get(counterState) >= 10
},
})
function Count() {
const count = useRecoilValue(counterState)
const biggerThan10 = useRecoilValue(isBiggerThan10)
return (
<>
<h3>{count}</h3>
<p>count is bigger than 10: {JSON.stringify(biggerThan10)}</p>
</>
)
}
export default function App() {
return (
<RecoilRoot>
<Counter />
<Count />
</RecoilRoot>
)
}

2. Jotai

Recoilatom 모델에 영감을 받아 만들어진 상태 관리 라이브러리다. 직관적인 API를 제공하며 UseAtom 훅을 사용한다.

import { atom, useAtom, useAtomValue } from 'jotai'
const counterState = atom(0)
function Counter() {
const [, setCount] = useAtom(counterState)
function handleButtonClick() {
setCount((count) => count + 1)
}
return (
<>
<button onClick={handleButtonClick}>+</button>
</>
)
}
const isBiggerThan10 = atom((get) => get(counterState) > 10)
function Count() {
const count = useAtomValue(counterState)
const biggerThan10 = useAtomValue(isBiggerThan10)
return (
<>
<h3>{count}</h3>
<p>count is bigger than 10: {JSON.stringify(biggerThan10)}</p>
</>
)
}
export default function App() {
return (
<>
<Counter />
<Count />
</>
)
}

3. Zustand

리덕스에 영감을 받아 만들어졌다. 간단한 API를 제공해 적고 가벼운 코드로 작성 가능하다.

import { create } from 'zustand'
const useCounterStore = create((set) => ({
count: 1,
inc: () => set((state) => ({ count: state.count + 1 })),
dec: () => set((state) => ({ count: state.count - 1 })),
}))
function Counter() {
const { count, inc, dec } = useCounterStore()
return (
<div class="counter">
<span>{count}</span>
<button onClick={inc}>up</button>
<button onClick={dec}>down</button>
</div>
)
}

작성하고 느낀 점

좋았던 점: 리덕스를 배울 때 왜 이렇게 복잡한 방식으로 API를 불러와야 하는지 이해가 어려웠었는데 오히려 이런 방식이 익숙해지면 안전하게 사용할 수 있다는 것을 알게 되었다.

배운 점: 상태 관리 라이브러리의 사용법을 배웠다.

아쉬웠던 점: 복잡한 내용을 어떻게든 전달하려는 의도가 느껴지지만 예제가 어렵다 보니 제대로 된 이해를 위해서 외부 정보를 이용해야 했다.

향후 계획: 리엑트 개발 도구로 디버깅 하는 방법에 대해 알아볼 것이다.